feat(disputes): dispute resolution with member voting and admin resolution - #250
Conversation
Implements issue JointSave-org#208. Database: - disputes table (type, target, evidence URLs, status lifecycle open -> voting -> resolved_upheld/resolved_dismissed/expired, resolution notes, vote counters) and dispute_votes table with one vote per wallet per dispute; RLS public-read + realtime publication. - Disputes auto-expire 72h after filing. API: - POST/GET /api/disputes — members file disputes (one active per filer per pool, description <=2000 chars, up to 3 validated evidence links) - POST /api/disputes/[id]/vote — member-only voting; filer and target excluded; auto-resolves when one side reaches half the pool (rounded up) and logs to pool_activity - POST /api/disputes/[id]/resolve — pool admin closes any active dispute with a required written resolution note - POST /api/disputes/expire — CRON_SECRET-gared expiry job registered in vercel.json Frontend: - useDisputes hook: feed loading + Supabase Realtime subscription + optimistic voting that reconciles with server truth - DisputesPanel / DisputeCard / FileDisputeDialog components wired into the group page below the main grid; status badges, live countdown, quorum-style vote counter, admin resolve dialog - lib/disputes.ts pure helpers with 8 node unit tests; en/es i18n
Sendi0011
left a comment
There was a problem hiding this comment.
Review — Dispute Resolution with Member Voting
Thorough implementation with proper server-side auth checks, rate limiting, optimistic UI updates with rollback, and Supabase Realtime. Two issues to address:
1. Disputes expire cron endpoint has no CRON_SECRET check (High)
frontend/app/api/disputes/expire/route.ts:283-290 — Wait, actually it does check CRON_SECRET. Good. But there's a subtle issue: the endpoint returns 500 when CRON_SECRET is not configured, which would prevent the cron from running silently. This is correct behavior — just flagging it.
2. Vote endpoint counts filer toward majority threshold (Medium)
frontend/app/api/disputes/[id]/vote/route.ts:222-227 — votesNeededToResolve(memberCount) uses Math.ceil(memberCount / 2) where memberCount includes the filer and target. But filers and targets cannot vote (canVoteOnDispute excludes them). If a pool has 3 members and 1 files a dispute against another, only 1 person can vote — but the threshold requires 2 votes (ceil(3/2)). This means disputes in small pools may never reach resolution through voting. Consider excluding filer + target from the member count when calculating the threshold, or at minimum document this limitation.
3. resolve endpoint does not verify dispute is in voting window (Low)
frontend/app/api/disputes/[id]/resolve/route.ts:93 — The admin resolve checks status is open or voting, but doesn't check expires_at. An admin could resolve a dispute whose voting window has already expired. The expire cron would then try to flip it to expired but the status would already be resolved_*. Not harmful but logically inconsistent.
What is good
- All endpoints have rate limiting (
readLimiter/writeLimiter) - Vote endpoint checks membership, filer/target exclusion, and one-vote-per-dispute (Postgres unique constraint)
- Disputes have a 72-hour expiry with a dedicated cron endpoint (properly protected by CRON_SECRET)
useDisputeshook does optimistic vote counts with rollback on failure — good UXdispute-card.tsxhas proper evidence URL rendering withnoopener noreferrer- All 5 CI green, mergeable state clean
Please address item 2 (threshold calculation). Items 1 and 3 are suggestions.
Closes #208
Summary
Adds a complete dispute resolution flow for pools: members can file disputes (against another member or an admin action), the pool votes to uphold or dismiss, and the admin can force-resolve with a written note.
Database (
20260824010000_dispute_resolution.sql)disputes: filer, optional target member, type (missed_deposit/unfair_penalty/admin_abuse/member_misconduct/other), description (≤2000 chars), up to 3 evidence URLs, status lifecycleopen → voting → resolved_upheld / resolved_dismissed / expired, vote counters, resolution note + resolver + timestampdispute_votes: one vote per wallet per dispute (composite PK)API
POST /api/disputesGET /api/disputes?pool_id=POST /api/disputes/[id]/votePOST /api/disputes/[id]/resolvePOST /api/disputes/expireCRON_SECRET-gared job that flips stale disputes to expired — registered invercel.jsonAll state changes are mirrored into
pool_activity(dispute_filed,dispute_resolved).Frontend
DisputesPanelrendered full-width on the group page: status badges, live voting countdown, vote counter showing threshold, resolution notesFileDisputeDialog: type select, optional target member picker, live character counter, repeatable evidence URL inputsuseDisputes: Supabase Realtime subscription keeps the feed live without refresh; optimistic vote updates reconcile against server truthlib/disputes.tspure helpers covered by 8 node unit tests (registered intest:unit); full en/es translationsVerification (all green locally)
pnpm lint,pnpm format:check,test:unit(210 pass incl. new disputes tests),test:components(101),pnpm build, bundle budget checktsc --noEmit)